package edu.uw.cs.cse461.sp12.OS;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.json.JSONException;
import org.json.JSONObject;

import edu.uw.cs.cse461.sp12.OS.DDNSException.*;
import edu.uw.cs.cse461.sp12.OS.DDNSRRecord.RecordType;
import edu.uw.cs.cse461.sp12.OS.HTTPDService.HTTPProvider;
import edu.uw.cs.cse461.sp12.util.IPFinder;
import edu.uw.cs.cse461.sp12.util.Log;

public class DDNSResolverService extends RPCCallable implements HTTPProvider {
	
	private static final String TAG="DDNSResolverService";
	
	public static final String RPC_RESPONSE_DDNSEXCEPTION = "ddnsexception";
	public static final String RPC_RESPONSE_RESOLVE = "resolveresult";
	public static final String RPC_RESPONSE_REGISTER = "registerresult";
	public static final String RPC_RESPONSE_UNREGISTER = "unregisterresult";
	
	private int ttl;
	private String rootServer;
	private int rootPort;
	
	private	ContinuousRegistrator registrator;
	
	private Map<DDNSFullName, CacheRecord> cache;

	public DDNSResolverService() {
		this.ttl = Integer.parseInt(OS.config().getProperty("ddns.ttl"));
		this.rootServer = OS.config().getProperty("ddns.rootserver");
		this.rootPort = Integer.parseInt(OS.config().getProperty("ddns.rootport"));
		
		// Set up the auto-renew thread
		this.registrator = new ContinuousRegistrator();
		Thread regThread = new Thread(this.registrator);
		regThread.start();
		
		// Set up the cache
		this.cache = new HashMap<DDNSFullName, CacheRecord>();
	}
	
	@Override
	public String servicename() { 
		return "ddnsresolver"; 
	}
	
	@Override
	public void shutdown() {
		registrator.shutdown();
	}

	private void cleanCache() {
		synchronized (this.cache) {
			// Go through and remove all old values
			List<DDNSFullName> removeList = new ArrayList<DDNSFullName>();
			for(DDNSFullName name : this.cache.keySet()) {
				CacheRecord cacheRecord = this.cache.get(name);
				if (cacheRecord.expired()) {
					// The value is old.  Remove from the cache
					removeList.add(name);
				}
			}
			for(DDNSFullName name : removeList) {
				this.cache.remove(name);
			}
		}
	}
	
	private CacheRecord getFromCache(DDNSFullName target) {
		synchronized(this.cache) {
			if (this.cache.containsKey(target)) {
				// Cache already has it
				return this.cache.get(target);
			} else {
				List<DDNSFullName> subDomains = target.getSubDomains();
				for(DDNSFullName name : subDomains) {
					if (this.cache.containsKey(name)) {
						return this.cache.get(name);
					}
				}
				return null;
			}
		}
	}
	
	private JSONObject performCall(String method, DDNSFullName target, String targetIP, int targetPort, String targetPassword) throws DDNSException {
		cleanCache();
		
		ResolverCallDetails details = new ResolverCallDetails(target);
		
		Log.v(TAG, "Method: " + method);
		try {
			// Keep going while have time to live
			for(int i = 0; i < this.ttl; i++) {
				Log.v(TAG, "Iteration " + i + ": [target=\"" + details.target.getName() + "\", hostName=\"" + details.hostName.getName() + 
						"\", ip=\"" + details.ip + "\", port=\"" + details.port + "\"]");

				boolean performRPC = true;
				
				// Check the cache
				CacheRecord record = getFromCache(details.target);
				
				if (record != null) {
					// Have part in the cache.
					if (record.target.equals(details.target)) {
						// Have the full result in the cache
						if (method.equals("resolve")) {
							// For resolve, know the answer already.  Otherwise, need to actually perform the register/unregister RPC
							Log.v(TAG, "Found target " + details.target + " in cache: " + record.toString());
							if (record.isEmpty()) {
								throw record.failingException;
							} else {
								JSONObject result = new JSONObject();
								switch (record.record.getType()) {
									case A:
										// Type A means return the result.
										result.put("node", record.record.toJSON());
										return result;
									case SOA:
										// Type SOA means return the result.
										result.put("node", record.record.toJSON());
										return result;
									case NS:
										// Type NS means we have a link to in a different zone.  Update ip and port
										details.changeNameServer(record.record);
										break;
									case CNAME:
										// Type CNAME means we need to update our target to the alias, also start to root server again
										details.changeTarget(record.record);
										performRPC = false;
										break;
									default:
										break;
								}
							}
						}
					} else {
						// Have one of the subdomains
						if (record.isEmpty()) {
							throw record.failingException;
						} else if (!details.hostName.containsRoot(record.record.getFullName())) {
							// Only check if subdomain is not a subdomain of the hostName
							Log.v(TAG, "Found subdomain for " + details.target + " in cache: " + record.toString());
							switch (record.record.getType()) {
								case A:
									// Type A doesn't help.  Only get these if it is the full result
									break;
								case SOA:
									// Type SOA means we are in a different zone.  Update ip and port
									details.changeNameServer(record.record);
									break;
								case NS:
									// Type NS means we have a link to in a different zone.  Update ip and port
									details.changeNameServer(record.record);
									break;
								case CNAME:
									// Type CNAME means we need to update our target to the alias, also start to root server again
									details.changeTarget(record.record);
									performRPC = false;
									break;
								default:
									break;
							}
						}
					}
				}
				
				if (performRPC) {
					Log.v(TAG, "Performing RPC: [target=\"" + details.target.getName() + "\", hostName=\"" + details.hostName.getName() + 
							"\", ip=\"" + details.ip + "\", port=\"" + details.port + "\"]");
					
					JSONObject requestArgs = new JSONObject();
					requestArgs.put("name", details.target.toString());
					
					if (method.equals("register") || method.equals("unregister")) {
						// Register and unregister need a password
						requestArgs.put("password", targetPassword);
					}
					if (method.equals("register")) {
						// Register needs an ip and port
						requestArgs.put("ip", targetIP);
						requestArgs.put("port", targetPort);
					}
					
					RPCCallerSocket socket = new RPCCallerSocket(details.hostName.getName(), details.ip, Integer.toString(details.port));
					JSONObject response = socket.invoke("ddns", method, requestArgs);
					socket.close();
	
					String resultType = response.getString("resulttype");
					if (resultType.equals(RPC_RESPONSE_DDNSEXCEPTION)) {
						// Had a DDNSException.
						DDNSException e = DDNSException.createFromJSON(response);
						if (method.equals("unregister")) {
							// Possible that the error was that unregistered the NS, which by protocol throws a DDNSNoAddressException
							if ((e instanceof DDNSNoAddressException)) {
								DDNSFullName errorName = new DDNSFullName(response.getString("name"));
								if (errorName.equals(target)) {
									// Had a DDNSNoAddressException with unregistering where the errorName was the same as our target
									// This is by protocol, so don't throw any error.  Can return null since unregister doesn't do 
									// anything with result
									Log.v(TAG, "Unregistering an NS node.  Should be fine");
									return null;
								}
							}
						}
						// Cache and throw the exception if not an unregister issue
						synchronized (this.cache) {
							this.cache.put(details.target, new CacheRecord(details.target, e));
						}
						throw e;
					} else if (resultType.equals(RPC_RESPONSE_RESOLVE) ||
							resultType.equals(RPC_RESPONSE_REGISTER) ||
							resultType.equals(RPC_RESPONSE_UNREGISTER)) {
						// Have a valid resultType
						
						boolean done = response.getBoolean("done");
						
						if (done) {
							if (method.equals("unregister")) {
								// Since unregistered, may no longer have a valid cache of this target
								if (this.cache.containsKey(details.target)) {
									this.cache.remove(details.target);
								}
							} else {
								// Cache a successful record (can't do so if unregistering)
								synchronized (this.cache) {
									this.cache.put(details.target, new CacheRecord(details.target, new DDNSRRecord(response.getJSONObject("node"))));
								}
							}
							// Return result
							return response;
						} else {
							DDNSRRecord node = new DDNSRRecord(response.getJSONObject("node"));
							switch (node.getType()) {
							case A:
								// Should never have this happen (A is only returned when done)
								throw new RuntimeException("Encountered an A DDNSRRecord when not done");
							case NS:
								Log.v(TAG, "Encountered NS Node: " + node.toJSON().toString());
								details.changeNameServer(node);
								synchronized (this.cache) {
									this.cache.put(node.getFullName(), new CacheRecord(node.getFullName(), node));
								}
								break;
							case SOA:
								// Should never have this happen (SOA is only returned when done)
								throw new RuntimeException("Encountered a SOA DDNSRRecord when not done");
							case CNAME:
								// Change the target to go to the alias, update the ip and port to start at the first server
								Log.v(TAG, "Encountered CNAME Node: " + node.toJSON().toString());
								details.changeTarget(node);
								synchronized (this.cache) {
									this.cache.put(node.getFullName(), new CacheRecord(node.getFullName(), node));
								}
								break;
							}
						}
					} else {
						throw new DDNSRuntimeException("Unknown Result Type: " + resultType);
					}
				}
			}
			// Reached end of loop...meaning ttl expired
			throw new DDNSTTLExpiredException("TTL Expired");
		} catch (IOException e) {
			Log.i(TAG, "IOException: " + e.getMessage());
		} catch (JSONException e) {
			Log.i(TAG, "JSONException: " + e.getMessage());
		}
		return null;
	}
	
	public DDNSRRecord resolve(DDNSFullName target) throws DDNSException {
		try {
			JSONObject response = performCall("resolve", target, DDNSRRecord.IP_NOT_SET, DDNSRRecord.PORT_NOT_SET, "");
			return new DDNSRRecord(response.getJSONObject("node"));
		} catch (JSONException e) {
			throw new RuntimeException(e);
		}
	}
	
	public DDNSRRecord register(DDNSFullName target, int port) throws DDNSException {
		String ip = IPFinder.getIpAddress();
		String password = OS.config().getProperty("host.password");
		
		return register(target, ip, port, password);
	}
	
	public DDNSRRecord register(DDNSFullName target, String ip, int port, String password) throws DDNSException {
		try {
			JSONObject response = performCall("register", target, ip, port, password);
			return new DDNSRRecord(response.getJSONObject("node"));
		} catch (JSONException e) {
			throw new RuntimeException(e);
		}
	}
	
	public void unregister(DDNSFullName target) throws DDNSException {
		String password = OS.config().getProperty("host.password");
		
		performCall("unregister", target, DDNSRRecord.IP_NOT_SET, DDNSRRecord.PORT_NOT_SET, password);
	}
	
	public void addContinuousRegistration(DDNSFullName target, int port) throws DDNSException {
		String password = OS.config().getProperty("host.password");
		
		addContinuousRegistration(target, port, password);
	}

	public void addContinuousRegistration(DDNSFullName target, int port, String password) {
		this.registrator.addTask(target, port, password);
	}
	
	/**
     * Serves web pages.  The 0th element of uriArray is always null.
     * The next element names this service ("ddnsresolver").  The optional third
     * component is a name to be resolved before dumping the cache.
     */
    @Override
    public String httpServe(String[] uriArray) {
    	StringBuilder builder = new StringBuilder();
    	builder.append("Cache:\n");
    	for(DDNSFullName name : this.cache.keySet()) {
    		builder.append("\t");
    		builder.append(name.getName());
    		builder.append("\t\t");
    		builder.append(this.cache.get(name).toString());
    		builder.append("\n");
    	}
    	return builder.toString();
    }
    
    private class ResolverCallDetails {
    	private DDNSFullName target;
    	private DDNSFullName hostName;
		private String ip;
		private int port;

		private ResolverCallDetails(DDNSFullName target) {
			this.target = target;
			this.hostName = new DDNSFullName("");
			this.ip = rootServer;
			this.port = rootPort;
		}
		
		private void changeTarget(DDNSRRecord cnameNode) throws DDNSException {
			this.target = this.target.replace(cnameNode.getFullName(), cnameNode.getAlias());
			this.ip = rootServer;
			this.port = rootPort;
		}
		
		private void changeNameServer(DDNSRRecord nsNode) throws DDNSException {
			this.hostName = nsNode.getFullName();
			this.ip = nsNode.getIP();
			this.port = nsNode.getPort();
		}
    }
    
    private class CacheRecord {
    	private DDNSFullName target;
    	private DDNSRRecord record;
    	private DDNSException failingException;
    	private long cachedTime;
    	private int cacheLifetime;
    	
    	private CacheRecord(DDNSFullName target, DDNSRRecord record) {
    		this.target = target;
    		this.record = record;
    		this.failingException = null;
    		this.cachedTime = System.currentTimeMillis();
    		this.cacheLifetime = Integer.parseInt(OS.config().getProperty("ddns.cache.lifetime"));
    	}
    	
    	private CacheRecord(DDNSFullName target, DDNSException failingException) {
    		this.target = target;
    		this.record = null;
    		this.failingException = failingException;
    		this.cachedTime = System.currentTimeMillis();
    	}
    	
    	private boolean isEmpty() {
    		return record == null;
    	}
    	
    	private boolean expired() {
    		return registrationExpired(this.cacheLifetime, this.cachedTime);
    	}
    	
    	@Override
    	public String toString() {
    		if (this.isEmpty()) {
    			// Result of a failed request.  Tell them the reason
    			return "[" + this.failingException.getClass().getCanonicalName() + ": " + this.failingException.getMessage() + "]";
    		} else {
    			return "[" + this.record.toString() + "]";
    		}
    	}
    }
    
    private class ContinuousRegistrator implements Runnable {
    	private static final String TAG = "ContinuousRegistrator";
    	
    	private static final int REREGISTER_TIME_LIMIT = 5;
    	
    	private Boolean requestShutdown;
    	private List<RegTask> tasks;
    	
    	public ContinuousRegistrator() {
    		this.requestShutdown = false;
    		this.tasks = new ArrayList<RegTask>();
    	}
    	
    	public void shutdown() {
    		synchronized (this.requestShutdown) {
    			this.requestShutdown = true;
    		}
    	}
    	
    	public void addTask(DDNSFullName target, int port, String password) {
    		synchronized (this.tasks) {
    			this.tasks.add(new RegTask(target, port, password));
    		}
    	}

		@Override
		public void run() {
			boolean continueLoop = true;
			
    		synchronized (this.requestShutdown) {
    			continueLoop = !this.requestShutdown;
    		}
    		
    		while (continueLoop) {
    			try {
	    			synchronized (this.tasks) {
	    				for (RegTask task : this.tasks) {
	    					try {
		    					// Re-register when have less than REREGISTER_TIME_LIMIT seconds remaining
		    					if (getLifetime(task.lifetime, task.registeredTime) < REREGISTER_TIME_LIMIT) {
			    					String ip = task.getIP();
		    						JSONObject response = performCall("register", task.name, ip, task.port, task.password);
		    						
		    						int lifetime = response.getInt("lifetime");
		    						task.updateTask(lifetime);
		    						Log.v(TAG, "Registered task " + task.name + ":");
		    						Log.v(TAG, "\t[lifetime=" + task.lifetime + ", registeredTime=" + task.registeredTime + "]");
		    					}
	    					} catch (Exception e) {
	    	    				Log.v(TAG, "Exception: " + e.getMessage());
	    	    				for (StackTraceElement ste : e.getStackTrace()) {
	    	        				Log.v(TAG, ste.toString());
	    	    				}
	    	    				// Update to run again in a minute (maybe problem will be fixed by then)
	    	    				task.updateTask(60);
	    					}
	    				}
	    			}
	    			
        			// Wait a second before checking each time
    				Thread.sleep(1000);
    				
    				boolean osShutdown = OS.isShutdown();
	        		synchronized(this.requestShutdown) {
	        			// Stop if we have been requested to shutdown (or if OS is shutdown...sometimes we miss this)
	        			continueLoop = !(this.requestShutdown || osShutdown);
	        		}
    			} catch (Exception e) {
    				Log.v(TAG, "Exception: " + e.getMessage());
    				for (StackTraceElement ste : e.getStackTrace()) {
        				Log.v(TAG, ste.toString());
    				}
    			}
    		}
		}
    	
    }
    
    private class RegTask {
    	private DDNSFullName name;
    	private int port;
    	private String password;
    	private int lifetime;
    	private long registeredTime;
    	
    	private RegTask(DDNSFullName target, int port, String password) {
    		this.name = target;
    		this.port = port;
    		this.password = password;
    		
    		this.lifetime = -1;
    		this.registeredTime = -1;
    	}
    	
    	private String getIP() {
    		return IPFinder.getIpAddress();
    	}
    	
    	private void updateTask(int lifetime) {
    		this.lifetime = lifetime;
    		this.registeredTime = System.currentTimeMillis();
    	}
    }
    
	public static int getLifetime(int lifeTime, long registeredTime) {
		if (registeredTime < 0) {
			return -1;
		} else {
			int timePassed = (int) (System.currentTimeMillis() - registeredTime) / 1000;
			int lifetime = lifeTime - timePassed;
			return lifetime;
		}
	}
	
	public static boolean registrationExpired(int lifeTime, long registeredTime) {
		return (getLifetime(lifeTime, registeredTime) < 0);
	}
	
}
